昨天我們提到 Node.js 非阻塞的特色,其實可以說 Node.js 到處都是非同步執行。
今天要來談怎麼利用計時器(timer)函式setTimeout
做出非同步執行的函數和用 Promise
包裝非同步函數。
我們可以由同步的程式,改寫成非同步函數。
考慮以下同步函數,addSum
是累加函數
// 同步
function addSum(numbers) {
let sum = 0;
numbers.forEach(number => sum = sum + number);
return sum;
}
const numbers = [1, 2];
console.log(addSum(numbers)); // 3
先想介面:怎麼回傳?透過一個 callback function
// 同步
function addSum(numbers, callback) {
let sum = 0;
numbers.forEach(number => sum = sum + number);
callback(sum);
}
const numbers = [1, 2];
addSum(numbers, sum => {
console.log(sum);
})
console.log('done');
結果:
3
done
addSum(numbers, callback)
看起來是不是有非同步的影子了阿。但他其實還是同步函數,只是在內部呼叫 callback(sum)
。
調用系統計時器 setTimeout()
// 非同步
function addSum(numbers, callback) {
setTimeout(() => {
let sum = 0;
numbers.forEach(number => sum = sum + number);
callback(sum);
}, 0);
}
const numbers = [1, 2];
addSum(numbers, sum => {
console.log(sum);
})
console.log('done');
結果:
done
3
雖然我們設定 0 秒,但不是指馬上執行,setTimeout()
會把裡面的 callback 放到 event loop 中的 queue (見 Day 12 - 二周目 - 準備起程深入後端)。當 addSum()
執行完後,console.log('done')
印出 done
,然後 event loop 就再迴圈一次,重新執行所有 queue 中的 callback function,發現我們有送入
() => {
let sum = 0;
numbers.forEach(number => sum = sum + number);
callback(sum);
}
就開始執行,callback
這變數因為箭頭函數產生閉包 (見:Day 5 - 一周目- 從VSCode debug 模式看作用域(Scope)、this、閉包(Closure)),如下圖:
callback
有值,值是我們送入的
sum => {
console.log(sum);
}
所以 callback(sum)
最後印出 3
這樣就完成了把同步函數 addSum(numbers)
轉成非同步函數 addSum(numbers, callback)
。
惡搞:你可以把
setTimeout()
改成setInterval()
,setInterval()
會一直在指定時間內重做(直到你clearInterval()
),我們改成 1000 ms,就會一直印出 3。
你可以想想為什麼程式停不了?因為 event loop 的 timer queue 的 callback 一直在的關係,queue 永遠非空。
需要自己做的非同步函數的情況,我還真的比較少發生,因為這表示你用到大量的CPU計算,這時候你應該開子行程(subprocess)處理,主行程改用非同步呼叫子行程處理,這個技巧未來會提。反而,大部分是要重新包裝非同步函數,或使用別人的非同步函數。
在 Node.js 中,也提供 nextTick, setImmediate,它們都可以用來做非同步函數,可以看以下文章
1. 詳解 setTimeout、setImmediate、process.nextTick 的區別
2. Node探秘之事件循環(2)--setTimeout/setImmediate/process.nextTick的差別
非同步操作如下(截錄自從Promise開始的JavaScript異步生活)
setTimeout
, setInterval
nextTick
, setImmediate
on('event name', callback)
訂閱1,2 常用來引起非同步,而 3,4 常用需要包裝非同步函數,使用我們的後端更有可讀性。
Promise
Promise
是一個物件,它遵從Promises/A+標準,下圖截錄自 Promises/A+標準定義
Promise 建立時是 pending 狀態,它可收到 value (透過resolve(value)
) 就被鎖定成 fulfilled,不然就是收到 reason (透過reject(reason)
, reason
一般是 Error
物件)就被鎖定成 rejected。鎖定後再也動不了。
Promise
物件的建立方法如下:
new Promise((resolve, reject) => {
});
(resolve, reject) => {}
這是 Promise 物件建立時就要立刻傳入的東西,也會立刻執行。
這裡我們可以用 resolve(value)
通知此 Promise resolve,也可以用 reject(error)
通知此 Promise reject。
addSum(number, callback)
:改變函數簽章 addSumPromise(numbers)
我們曾提過 Pomise 方便的地方在於可以用 then().catch()
鍊式語法,所以我們把 addSum(numbers, callback)
包裝成 addSumPromise(numbers)
,它回傳 Promise 物件,就可以用鍊式語法
// 包裝非同步回傳 Promise
function addSum(numbers, callback) {
setTimeout(() => {
let sum = 0;
numbers.forEach(number => sum = sum + number);
callback(sum);
}, 0);
}
function addSumPromise(numbers) {
return new Promise((resolve, reject) => {
addSum(numbers, sum => {
resolve(sum);
})
});
}
const numbers = [1, 2];
addSumPromise(numbers)
.then(sum => {
console.log(sum);
})
console.log('done');
我們利用回傳 Promise 物件,可以把 callback 參數拿掉,就可以享有鍊式語法。
addSumPromise()
參數處理有沒有發現我們的 addSum()
很脆弱,可以加入一些判斷使它強健一點。例如我們要確保 numbers 是 Array,若不是的話就回傳的 reject promise
// 包裝非同步回傳 Promise
function addSum(numbers, callback) {
setTimeout(() => {
let sum = 0;
numbers.forEach(number => sum = sum + number);
callback(sum);
}, 0);
}
function addSumPromise(numbers) {
return new Promise((resolve, reject) => {
if(!Array.isArray(numbers)) {
reject(new Error('numbers is not a Array'));
return; // 這行要寫,雖然Promise 狀態不變,但下面的程式一樣會執行
}
addSum(numbers, sum => {
resolve(sum);
})
});
}
const numbers = {};
addSumPromise(numbers)
.then(sum => {
console.log(sum);
})
console.log('done');
就可以得到addSumPromise({})
會得到一個 reject promise。我們注意以下幾點:
console.log('done')
一樣有執行,不會因為 reject promise。所以改成下面
addSumPromise(numbers)
.then(sum => {
console.log(sum);
})
.catch(error => {
console.error(error);
})
這麼一來 Promise 就處理完所有的非同步的情況了。
我們舉了 addSum(numbers, callback)
為例子,再用 Promise 包裝改成像是 addSum(number)
。早期非同步函數中,常常有 callback function 當參數,callback 的簽章一般是(err, value) => ...
,常會利用上述方法包裝成 Promise 的版本方便使用。另外,Node.js 也提供 util.promisify() 快速轉換。
最近的套件有時也會同時提供兩種版本(callback版或回傳Promise),像是 Node.js MongoDB Driver API 或是 fs-extra
。 fs-extra
我會拿來取代原生的 fs
,它也提供好用的函數。另外,在Node.js 10 以後,fs.promises
也開始支援 Promise版。
then().catch()
鍊式語法:Promise 物件好用之處then().catch()
可以說是 Promise 的核心之一,then()
和 catch()
函數被 Promises/A+ 規定要回傳 Promise,他可以讓我們的非同步操作串接起來。
then(callback)
/catch(callback)
的 callback 叫起與回傳then(callback)
/catch(callback)
中的 callback 被叫起:
aPromise.then(callback)
:當 aPromise resolve時,resolve value 送入 callback(value)aPromise.catch(callback)
:當 aPromise reject,reject reason 送入 callback(reason)then(callback)
/catch(callback)
中的 callback 可以回傳:
then()
會回傳 resolve promise
.then(() => ({name: 'Billy'})) // resolve promise
then()
會和回傳的 promise 同樣狀態
.then(() => Promise.resolve()) // resolve promise
.then(() => Promise.reject()) // reject promise
這裡的:
Promise.resolve()
直接回傳 reolve promise 物件;Promise.reject()
直接回傳 reject promise 物件
其實:
then()
的完整簽章是:then(resoveCallback, rejectCallback)
,這個我比較少用,反而常用then(resoveCallback).catch(rejectCallback)
例如,我們要查詢一個人的訂單要做兩個非同步的查詢:
fetchPerson(name)
- 查人fetchOrders(person)
- 查此人的訂單// then() 鍊式
function fetchOrders(person) {
const orders = person.orderIds.map(id => ({ id }));
return Promise.resolve(orders); // 直接回傳 reolve promise 物件
}
function fetchPerson(name) {
// return Promise.reject(new Error('name is not string')); // 直接回傳 reject promise 物件
return Promise.resolve({
name,
orderIds: ['A', 'B']
});
}
fetchPerson('Billy')
.then(fetchOrders)
.then(orders => {
orders.forEach(order => {
console.log(order);
})
})
.catch(console.error);
我們利用 then()
把非同步操作串接起來,且 fetchPerson()
、fetchOrders()
任一個Promise 發生 reject 才會引起 .catch()
發生。若前面的任一個Promise reject,後面的 then()
都不會發生直到 catch()
。(你可以把解開註解看看 // return Promise.reject(new Error('name is not string'));
)
.catch()
常犯的錯:.catch(callback)
可能是回傳 resolve promise在用鍊式時你可能會想要截斷某個reject,查看結果,常會寫以下的程式
Promise.resolve(1)
.then(() => Promise.reject(new Error('error 1')))
.catch(console.error)
.then(() => Promise.resolve(2))
.then(console.log)
.catch(console.error)
這結果是
Error: error 1
2
這是因為 .catch(console.error)
寫清楚一點就是
function catch(error) => {
return console.error(error); // console.error() 回傳 undefined
}
因為回傳 undefined,所以 .catch()
的回傳是 resolve promise,這使下一行的 .then(() => Promise.resolve(2))
執行。因此,你若要保持 reject 往下傳,要用 Promise.reject()
Promise.resolve(1)
.then(() => Promise.reject(new Error('error 1')))
.catch(error => {
console.error(error);
return Promise.reject(error);
})
.then(() => Promise.resolve(2))
.then(console.log)
.catch(console.error)
才會得到
Error: error 1
Error: error 1
一但進入 Promise後在內部的執行不論在哪丟出例外都會導致 promise reject
// Promise 的例外發生
function somePromise() {
return new Promise((resolve, reject) => {
// throw new Error('constructor error');
resolve();
});
}
somePromise()
.then(sum => {
// throw new Error('resolve error');
console.log('done');
})
.catch(error => {
console.error(error);
});
你可以解開註解看看,都是會產生 reject promise。
這事實可以看成是,Promise 幫我們包住了所有例外,某方面降低了程式當掉的可能。
今天如何利用 setTimeout()
做非同步函數,還用 Promise 包裝它,且介紹 then().catch()
鍊式語法。